Skip to content

fix(store): key 格式改用全局索引,摆脱 list 配额并提速 (#125) - #192

Open
PIKACHUIM wants to merge 1 commit into
mainfrom
fix/125-kv-key-index
Open

PIKACHUIM wants to merge 1 commit into
mainfrom
fix/125-kv-key-index

Conversation

@PIKACHUIM

Copy link
Copy Markdown
Member

PR 正文 — KV + DB_FORMAT=key 全局索引与并发读写

fix(store): key 格式改用全局索引,摆脱 list 配额并提速 (#125)

Summary / 摘要

DB_FORMAT=key 在 KV 上每次 load / save 都要用 list() 枚举全部键名。即使 #127 已把「逐表 list 6 次」合并成「全局 list 1 次」,Cloudflare 免费版 1000 次/天的 list 配额仍只够约 1000 次页面请求,配额耗尽后初始化与保存都会 500。

本 PR 给 key 格式加一个全局索引键 openlist_key_index,并把握手式的逐条读写改成受限并发:

  1. 稳态不再调用 list():索引记录当前存活的实体键名,load / save 只用 get / put / delete。
  2. 旧部署自动迁移:索引缺失时回退 1 次 list,读完后补写索引,无需手工操作。
  3. 损坏/超大索引安全回退:解析失败、版本不符、超过单值上限时一律回退 list,绝不当成空库。
  4. 读写并发化:逐条 get / put 改为并发 12,往返轮次从 N 降到 ceil(N/12)。
  5. 健康检查少花配额:索引存在时 env_check 不再为了探测而消耗 1 次 list。

Motivation / 背景

Issue #125 的两个层面:

  • 字段语义:fix(store/kv): binding list 兼容 Cloudflare 语义并收敛 list 配额消耗 #127 已修复 kvDriver.list() 在 Cloudflare 上读 item.key / page.complete 导致的重复翻页与配额爆炸。
  • 配额与延迟:字段修好后,key 格式每次 load / save 仍要一次全局 list。免费版 KV 的 list / write / delete 各 1000 次/天,一次访问就可能消耗多次;同时默认配置有 100+ 条记录,逐条串行 get 在边缘网络上会把单次请求拖到数秒。

因此需要把「成员名单」从 list() 的返回值变成可持久化的数据,并缩短读写链路。

Changes / 改动内容

1. 新增索引键(store/keycodec.ts)

export const KEY_INDEX_KEY = "openlist_key_index"
  • 只含字母/数字/下划线,满足 EdgeOne KV 键名限制;
  • 不以任何表前缀开头(settings_ / storages_ / users_ / shares_ / metas_ / plugins_),不会被 groupKeysByTable 当成实体记录,也不会被陈旧键清理误删。

索引值结构:

{ "v": 1, "keys": ["metas_1", "settings_site_title", "users_1"] }

只存键名,不复制记录内容;记录值仍以实体键为唯一权威。

2. load()(store/format/key.ts)

get(openlist_config)      // 初始化标记
get(openlist_key_index)   // 1 次读索引
get(<实体键>) × N         // 并发 12
  • 索引存在:直接按索引读取,0 次 list。
  • 索引缺失:回退 list("") 1 次,分组后补写索引(best-effort,失败不影响本次读取)。
  • 索引损坏 / 版本不符 / 非对象:忽略并回退 list,随后重建。
  • 补写的索引只收录实体键,不包含 openlist_config、索引键或其他应用的键。

3. save()(store/format/key.ts)

put(<实体键>) × N          // 并发 12,Map 去重
get(openlist_key_index)    // 旧名单(缺失时 list 一次)
put(openlist_key_index)    // 新名单
delete(<陈旧键>) × M       // 并发 12
put(openlist_config)       // 初始化标记

写入顺序刻意固定为「记录 → 索引 → 删旧键」:

  • 若索引写失败:删除旧索引并抛错,下一次 load 走 list 兜底,已写入的记录仍可见;
  • 若删旧键失败:索引已不含它们,读取不会看到残留键,最多浪费存储;
  • 不会出现「索引是旧名单、记录已删除」的中间态。

陈旧键清理只处理已知表前缀,避免误删 openlist_config、索引键或同命名空间下其他应用的键。

4. 并发读写(runBounded)

  • 并发上限 IO_CONCURRENCY = 12;
  • 结果顺序与输入一致,记录顺序仍按键名字典序,与 KV list 的返回顺序一致;
  • 避免一次性 Promise.all 上千条子请求撞平台限制,也避免串行的线性延迟。

5. 细粒度方法同步索引

getTable / saveTable / saveRecord / deleteRecord 在索引存在时同步维护名单,避免这些路径写入/删除记录后 load 漏读:

  • getTable:有索引时按前缀过滤,不回退 list;
  • saveTable:先写新记录,再替换索引中该表的键,最后删旧键;
  • saveRecord / deleteRecord:写/删记录后同步增删索引键(索引缺失时保持缺失,由下次 load 重建)。

6. 健康检查(store/driver/kv.ts)

  • 把 get 的两种签名兼容逻辑抽成 kvGetText(),get() 与 health() 共用;
  • key 格式下先读 openlist_key_index:
    • 索引存在 → 不再调用 list(稳态读写本来也不需要);
    • 索引缺失 → 仍探测 1 次专用前缀的 list,确保首次迁移/兜底路径可用;
  • 仍保留 typeof kv.list !== "function" 的能力校验(EdgeKV 无 list 时 key 格式不可用)。

7. 文档

README.md 与 readmes/README_en.md 增加 KV 配额提示:list 配额问题已在稳态解决,但每次保存仍按记录数写入,长期建议 DB_DRIVER=d1 或 DB_FORMAT=map。

配额对照

以默认配置约 118 条记录为例:

操作 改动前 改动后(索引已存在)
一次 load 1 次 list + 118 次 get 2 次 get + 118 次 get(并发 12)
一次 save 1 次 list + 118 次 put 1 次 get + 120 次 put + M 次 delete(并发 12)
list 配额 每次 load/save 各 1 次 0 次(仅旧数据首次迁移 / 索引异常时 1 次)
list 翻页 大库可能多页 稳态无翻页

Cloudflare 免费版每天 1000 次 list / write / delete、100000 次 read:

  • list 配额不再被 key 格式的常规读写消耗;
  • read 配额仍然存在:默认库每次请求约 120 次读,理论上限约 800 次请求/天,高流量部署仍建议 map 或 d1;
  • write 配额不变:每次保存按记录数写入(默认约 120 次),免费版 1000 次/天。

行为矩阵

场景 行为
正常部署(索引存在) load/save 全程 0 次 list
旧部署首次升级(无索引) 首次 load 1 次 list,随后补写索引;之后 0 次 list
索引被手工删除 同「旧部署首次升级」
索引不是合法 JSON / 结构不符 / 版本不符 忽略索引,回退 list 并重建
索引超过 900 KB(EdgeOne 单值 1 MB) 删除旧索引、放弃索引,持续走 list;首次告警后不再刷屏
索引写入失败 删除旧索引并抛错,下次 load 走 list,数据仍可见
删除陈旧键失败 索引已排除这些键,不影响读取
同一 id 重复出现 只写入最后一次,索引也只保留一个键
saveTable / saveRecord / deleteRecord 索引存在时同步维护,避免漏读
KV 绑定不支持 list key 格式仍判定不可用(迁移/兜底路径需要)
map 格式 完全不涉及索引与 list,行为不变

Testing / 验证

# 存储格式与 KV 驱动(含新增回归)
npx tsx --test src/backend/internal/model/store/store.test.ts \
  src/backend/internal/model/store/driver/kv.test.ts
# 28 passed / 0 failed

# 类型检查
npx tsc -p tsconfig.json --noEmit
# 无报错

# 回归脚本
npx tsx scripts/_regress.mjs
# 71/71 通过

# model 测试
npm run test:model
# 39 passed / 0 failed

新增测试覆盖:

  • key format: global index replaces list() in steady state:首次 save 1 次 list,之后 load/save 均为 0 次;
  • key format: legacy layout without index is migrated on first load:删掉索引后首次 load 回退 list 并补写,第二次 0 次 list;
  • key format: corrupt index falls back to list and is rewritten:损坏索引不当空库,读取完整并重建;
  • key format: stale records are deleted using the index:索引存在时保存不 list,且旧键被清理、索引同步更新;
  • key format: records are fetched with bounded concurrency:读取确实并发且不超过 12;
  • kv health (binding, key format) skips list when the key index exists:索引存在时健康检查 0 次 list。

已知的无关失败:

npx tsx --test src/backend/server/storage_update_guangyapan.test.ts
# 1 failed(addition.captcha_token undefined !== 'ct1')

该失败在未应用本 PR 的干净代码上同样复现(已用 git stash 验证),属 main 上预先存在的问题,与本改动无关。

Compatibility / 兼容性

  • 无数据库结构变更、无新增环境变量、无前端改动。
  • 新增 1 个键 openlist_key_index(每次 save 1 次写);旧版本将其视为未知键,不会读取也不会删除。
  • 旧版本回退运行时忽略索引、继续走 list,数据布局完全兼容。
  • 索引只是成员名单:删除索引不会丢数据,只会让下一次 load 多消耗 1 次 list。
  • Cloudflare KV 最终一致:索引与记录一样可能短暂滞后(最长约 60 秒),与改动前 list 的可见性语义一致。
  • 多实例并发保存仍是「最后写入者获胜」,陈旧键删除的竞争窗口与改动前相同。

Known boundaries / 已知边界

  • read 配额:每次请求仍需读取全部记录(默认约 120 次读)。本 PR 解决的是 list 配额与延迟,不改变「key 格式按请求全量加载」的模型;高流量部署建议 map(单键读取)或 d1(单查询)。
  • write 配额:每次保存按记录数写入,免费版 KV 的 1000 次/天仍会限制更新频率(默认配置约 8 次保存/天)。
  • 超大索引:索引超过 900 KB 时放弃索引并回退 list,此时 list 配额压力回到改动前水平;该规模建议直接使用 d1 / sql / map。
  • 孤儿键:索引存在时不再扫描全量键,历史上残留且不在索引中的实体键不会被自动清理(只占存储,不影响读取)。
  • 代理模式:索引同样经 /kv-get / /kv-put 代理读写,行为与 binding 模式一致。

Checklist

  • 稳态 load/save 0 次 list
  • 旧部署自动补建索引,无需手工迁移
  • 索引损坏/超大/写失败均有回退路径,不会当空库
  • 写顺序保证失败时不丢已写入记录
  • 读写并发有上限且顺序稳定
  • 健康检查不再无谓消耗 list
  • store / kv 测试 28 项通过
  • tsc --noEmit 通过
  • 回归脚本 71/71 通过
  • 无新增依赖 / 环境变量 / 数据库迁移
  • README 已补充配额说明

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
❌ Deployment failed
View logs
openlist-work f2a92d9 Nov 10 2026, 07:37 AM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
openlist-tsworkers f2a92d9 Nov 10 2026, 07:37 AM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Cloudflare KV binding 模式下 kvDriver.list() 沿用 EdgeOne 语义,字段名不匹配导致 key 格式下初始化 500 并耗尽 list 配额

1 participant